SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
9.0 KB · 164 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { DecaysByMonth, DeploymentTimeline, MembershipNote, OrbitalShells, StatusDonut } from '@/components/entities/constellation-panels';5import { Block, EventsList, ExternalLink, HeroFacts, KpiStrip, PlannedUnavailable, Tag, entityMetadata } from '@/components/entities/shared';6import { LaunchesTable, SatellitesTable, SitesTable } from '@/components/entities/tables';7import { OrbitBadge } from '@/components/ui/badges';8import { Container } from '@/components/ui/section';9import { Unavailable } from '@/components/ui/unavailable';10import { ApiError, api } from '@/lib/api';11import { fmt1, fmtDate, fmtDateTime, fmtInt, fmtPct, num, titleCase } from '@/lib/format';12import { SITE_URL, routes } from '@/lib/site';13import type { ConstellationDetail } from '@/lib/types';1415type Props = { params: Promise<{ slug: string }> };1617async function load(slug: string): Promise<{ d: ConstellationDetail; generatedAt: string } | null> {18  try {19    const res = await api.constellation(slug);20    return { d: res.data, generatedAt: res.meta.generated_at };21  } catch (e) {22    if (e instanceof ApiError && e.notFound) return null;23    throw e;24  }25}2627export async function generateMetadata({ params }: Props): Promise<Metadata> {28  const { slug } = await params;29  const r = await load(slug).catch(() => null);30  if (!r) return { title: 'Constellation not found', robots: { index: false } };31  const { d } = r;32  const desc = `${d.name}${d.operator_name ? ` (${d.operator_name})` : ''}: ${fmtInt(d.active)} active satellites, ${fmtInt(d.total)} launched since ${fmtDate(d.first_launch)}. ${d.orbit_class ?? ''} ${titleCase(d.service_type)} constellation — deployment timeline, orbital shells, launch history and status.`;33  return entityMetadata({ title: `${d.name} — ${fmtInt(d.active)} active satellites, ${d.orbit_class ?? 'mixed'} constellation`, description: desc, path: routes.constellation(d.slug), ogImage: `${SITE_URL}${routes.constellation(d.slug)}/opengraph-image` });34}3536export default async function ConstellationPage({ params }: Props) {37  const { slug } = await params;38  const r = await load(slug);39  if (!r) notFound();40  const { d, generatedAt } = r;41  const active = num(d.active);42  const planned = d.planned_count;43  const plannedPct = planned && active !== null ? (active / planned) * 100 : null;4445  const jsonLd = {46    '@context': 'https://schema.org',47    '@type': 'Dataset',48    name: `${d.name} constellation`,49    description: d.description ?? `${d.name} satellite constellation tracked by SatelliteIndex`,50    url: `${SITE_URL}${routes.constellation(d.slug)}`,51    creator: d.operator_name ? { '@type': 'Organization', name: d.operator_name, url: d.operator_slug ? `${SITE_URL}${routes.operator(d.operator_slug)}` : undefined } : undefined,52    variableMeasured: ['active satellites', 'satellites on orbit', 'launches'],53  };5455  return (56    <Container wide>57      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />5859      {/* Hero */}60      <header className="pb-6 pt-8 md:pb-8 md:pt-12">61        <nav aria-label="Breadcrumb" className="eyebrow">62          <Link href={routes.constellations()} className="hover:text-ink">Constellations</Link> <span aria-hidden>/</span> {d.name}63        </nav>64        <div className="mt-3 flex flex-wrap items-center gap-2">65          <OrbitBadge orbitClass={d.orbit_class} />66          {d.service_type && <Tag>{titleCase(d.service_type)}</Tag>}67          <Tag tone={d.lifecycle_stage === 'OPERATIONAL' ? 'accent' : 'warn'}>{titleCase(d.lifecycle_stage.toLowerCase())}</Tag>68        </div>69        <h1 className="display mt-3 text-3xl md:text-5xl">{d.name}</h1>70        <p className="mt-3 max-w-2xl text-[15px] text-ink-2 md:text-base">71          {d.operator_slug ? <Link href={routes.operator(d.operator_slug)} className="link">{d.operator_name}</Link> : d.operator_name ?? 'Operator unavailable'}72          {d.country_slug && (73            <>74              {' '}· <Link href={routes.country(d.country_slug)} className="link">{d.country_name}</Link>75            </>76          )}77        </p>78        {d.description && <p className="mt-4 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}79        <HeroFacts80          items={[81            { label: 'Official site', value: d.official_url ? <ExternalLink href={d.official_url} /> : null },82            { label: 'Deployment vs plan', value: planned ? `${fmtInt(active)} of ${fmtInt(planned)} planned (${fmtPct(plannedPct, 0)})` : null },83            { label: 'Authorized', value: d.authorized_count ? `${fmtInt(d.authorized_count)} satellites` : null },84            { label: 'Median orbit', value: num(d.median_perigee_km) !== null ? `${fmtInt(d.median_perigee_km)} km · ${fmt1(d.median_inclination_deg)}°` : null },85          ]}86        />87      </header>8889      <KpiStrip90        items={[91          { label: 'Active', value: <span className="text-active">{fmtInt(d.active)}</span> },92          { label: 'Inactive', value: fmtInt(d.inactive) },93          { label: 'Decayed', value: fmtInt(d.decayed) },94          { label: 'On orbit', value: fmtInt(d.on_orbit) },95          { label: 'Total launched', value: fmtInt(d.total) },96          { label: 'Launches', value: fmtInt(d.launches) },97          { label: 'Launched 30 d', value: fmtInt(d.launched_last_30d) },98          { label: 'Launched 365 d', value: fmtInt(d.launched_last_365d) },99          { label: 'First launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.first_launch)}</span> },100          { label: 'Last launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.last_launch)}</span> },101          { label: 'Activity score', value: <span className="text-accent-2">{fmt1(d.activity_score)}</span>, derived: true, hint: 'launch cadence index' },102          { label: 'Snapshot', value: <span className="text-base text-ink-2 md:text-lg">{fmtDateTime(generatedAt)}</span> },103        ]}104      />105106      {/* Terminal layout: main analysis + right telemetry column */}107      <div className="grid gap-x-10 lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">108        <div className="min-w-0">109          <Block eyebrow="Deployment" title="Deployment timeline" id="timeline">110            <DeploymentTimeline growth={d.growth} />111          </Block>112          <Block eyebrow="Orbits" title="Orbital shells" id="shells">113            <OrbitalShells d={d} />114          </Block>115          <Block eyebrow="Launches" title={`Launch history · ${fmtInt(d.launches)} launches`} id="launches">116            <LaunchesTable rows={d.launches_list} showActive />117            {d.launches_list.length < (num(d.launches) ?? 0) && <p className="mt-2 text-xs text-ink-3">Showing the {fmtInt(d.launches_list.length)} most recent launches.</p>}118          </Block>119          <Block eyebrow="Fleet" title="Recent satellites" id="satellites" action={{ href: routes.satellites(`constellation=${encodeURIComponent(d.slug)}`), label: 'All satellites in this constellation' }}>120            <SatellitesTable rows={d.recent_satellites} columns={['perigee', 'apogee', 'inclination']} />121          </Block>122          {d.decays_by_month.length > 0 && (123            <Block eyebrow="Reentries" title="Decays by month" id="decays">124              <DecaysByMonth rows={d.decays_by_month} />125            </Block>126          )}127          <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`entity=${encodeURIComponent(d.id)}`), label: 'All events' }}>128            <EventsList events={d.events} />129          </Block>130        </div>131132        <aside className="min-w-0 lg:border-l lg:border-rule lg:pl-10">133          <Block eyebrow="Status" title="Status distribution">134            <StatusDonut dist={d.status_distribution} total={num(d.total)} />135          </Block>136          <Block eyebrow="Ground" title="Launch sites">137            <SitesTable rows={d.launch_sites} showSatellites />138          </Block>139          <Block eyebrow="Registry" title="Countries">140            {d.countries.length === 0 ? (141              <Unavailable what="Country attribution" compact />142            ) : (143              <ul className="divide-y divide-rule text-sm">144                {d.countries.map((c) => (145                  <li key={c.code} className="flex items-center justify-between gap-3 py-2">146                    <Link href={routes.country(c.slug)} className="link">{c.name} <span className="mono ml-1 text-xs text-ink-3">{c.code}</span></Link>147                    <span className="tnum">{fmtInt(c.satellites)}</span>148                  </li>149                ))}150              </ul>151            )}152          </Block>153          <Block eyebrow="Regulatory" title="Regulatory filings">154            <PlannedUnavailable what="Regulatory filings" note="FCC / ITU connectors are planned; filings will appear here with source attribution once ingested." />155          </Block>156          <Block eyebrow="Methodology" title="How membership is determined">157            <MembershipNote d={d} />158          </Block>159        </aside>160      </div>161    </Container>162  );163}164